查看原文
其他

【第1552期】深入React fiber 链表和DFS

张意政 前端早读课 2019-12-04

前言

今日早读文章由@张意政翻译授权分享。

@张意政,美团前端工程师,兴趣广泛,热爱技术,全才。

正文从这开始~~

在React中,变更检测机制被称为reconciliation或rendering,fiber是其最新的实现。由于良好的底层架构,其提供了很多强大的特性,包括非阻塞渲染,按优先级更新,预渲染等,这些特性被称为time-slicing。

除了解决应用开发工程师遇到的问题,这种机制的内部实现在工程角度也有不错的吸引力,其中的很多不错的实践,有助于帮助工程师快速成长。

如果使用Google搜索“React Fiber”,你会在搜索结果中看到很多文章。但是,除了Andrew Clark的笔记外,其他的内容会更加宏观。在本文中,我将参考此资源,并对Fiber中一些特别重要的概念进行详细说明。一旦理解了这些内容,你将有足够的知识来理解Lin Clark在ReactConf 2017演讲(主要内容是work loop)。

背景

Fiber的架构有两个主要阶段:render和commit。在源代码中,reconciliation阶段主要称为“render阶段”。这是React遍历组件树的阶段,并且:

  • 更新state和props,

  • 调用lifecycle hooks

  • 从组件中检索children

  • 将新的children与老的children比较

  • 确定需要执行的DOM 更新

所有这些活动都被称为fiber内部的work,需要完成的工作类型取决于React Element的类型。例如,对于class组件,React需要实例化一个类,而不是为function组件执行它。如果有兴趣,在这里可以看到Fiber中的所有类型的工作目标。这些活动正是Andrew clark在这里谈到的:

在处理UI的时候,最大的问题是如果一次执行太多的任务,那么将会导致掉帧

React要同步遍历整个组件树并为每个组件执行工作,它可能会运行超过16毫秒,以便应用程序代码执行其逻辑。从上面可以看到,如果太多的work一次执行,那么会引起掉帧,整个应用就会显得卡顿。

所以该怎么办呢?

新的浏览器(和React Native)实现了能够解决这个问题的新API

他谈到的新API是requestIdleCallback全局函数,可用于对在浏览器空闲期间调用的函数进行排队。可以这样来使用:

requestIdleCallback((deadline)=>{
console
.log(deadline.timeRemaining(), deadline.didTimeout)
});

如果我们现在打开浏览器控制台,并执行上面的代码,Chrome会返回49.9, false。它告诉我们有49.9毫秒时间做需要做的任何工作,并且还没有用完分配的时间;否则deadline.didTimeout的值将会true。请记住,deadline.timeRemaining() 可以在浏览器完成某些工作后立即更改,因此应该不断检查。

requestIdleCllback 有太多的限制,requestIdleCallback实际上有点过于严格,并且执行得不够频繁,无法实现流畅的UI呈现,因此React团队必须实现自己的版本。

现在,如果我们将React对组件执行的所有活动放入函数performWork,并使用requestIdleCallback来执行,我们的代码可能看起来像这样:

requestIdleCallback((deadline) => {
// while we have time, perform work for a part of the components tree
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
nextComponent
= performWork(nextComponent);
}
});

我们对一个组件执行work,然后切换到下一个组件继续执行。但是我们无法同步处理整个组件树,如以前的协调算法实现中所示。这就是安德鲁在这里谈到的问题:

为了实现这些API,不得不将渲染的任务拆分成增量的形式

为了解决这个问题,React必须重新实现遍历树的算法,从依赖于内置调用栈的同步递归模型到具有链表和指针的异步模型。这就是安德鲁在这里写的:

如果你只依赖于[内置]调用堆栈,它会继续工作直到堆栈为空……如果我们可以随意中断调用堆栈并手动操作堆栈帧,那会不会很好?这就是React Fiber的目的。 Fiber重新实现le堆栈,专门用于React组件。您可以将单根fiber视为虚拟堆栈帧

关于栈(stack)

假设大家都熟悉调用堆栈的概念,如果您在断点处暂停代码,则可以在浏览器的调试工具中看到这一点。以下是维基百科的一些相关引用:

在计算机科学中,调用堆栈是一种堆栈数据结构,它存储有关计算机程序的活动子程序的信息…具有调用堆栈的主要原因是跟踪每个活动子程序在完成执行时应返回控制的点…调用堆栈由堆栈帧组成…每个堆栈帧对应于对尚未以返回终止的子例程的调用。例如,如果一个名为DrawLine的子程序当前正在运行,由子程序DrawSquare调用,则调用堆栈的顶部可能会像在相邻的图片中那样布局

为什么react与堆栈相关?

正如我们在本文的第一部分中所定义的,Reacts在reconciliation/render阶段,会在组件树上为组件执行一些工作。以前的reconciler实现,依赖于内置堆栈的同步递归模型来遍历树,关于reconciliation的官方文档描述了这个过程,并谈论了很多关于递归的内容:

默认情况下,当对DOM节点的子节点进行递归时,React会同时迭代两个子节点列表,并在出现不同时生成mutation

可以想象一下,每次递归调用都会向堆栈添加一个帧,它会同步执行。假设我们有以下组件树:

我们把render函数当作object,可以将它们视为组件的实例:

const a1 = {name: 'a1'};
const b1 = {name: 'b1'};
const b2 = {name: 'b2'};
const b3 = {name: 'b3'};
const c1 = {name: 'c1'};
const c2 = {name: 'c2'};
const d1 = {name: 'd1'};
const d2 = {name: 'd2'};

a1
.render = () => [b1, b2, b3];
b1
.render = () => [];
b2
.render = () => [c1];
b3
.render = () => [c2];
c1
.render = () => [d1, d2];
c2
.render = () => [];
d1
.render = () => [];
d2
.render = () => [];

React需要迭代组件树并为每个组件执行工作,为简化起见,我们假定要做的工作是记录当前组件的名称并检索其子组件。我们通过递归的形式来处理:

递归遍历

遍历树的函数就是下面的walk:

walk(a1);

function walk(instance) {
doWork
(instance);
const children = instance.render();
children
.forEach(walk);
}

function doWork(o) {
console
.log(o.name);
}

输出是:

a1, b1, b2, c1, d1, d2, b3, c2

递归方法比较直观,非常适合遍历树。但正如我们发现的那样,它有局限性:最大的一点是我们不能将工作分解为增量单元。我们不能暂停特定组件的工作并在以后恢复。使用这种方法,React只是继续迭代,直到它处理所有组件并且堆栈为空。所以就会造成前面提到的:如果一次处理太多的任务,就会造成掉帧。

那么React如何实现在没有递归的情况下遍历树呢?它使用单链表树遍历算法。它可以暂停遍历。

链表遍历

很幸运的找到了Sebastian Markbåge关于该算法的总结。为了实现该算法,需要一个包含三个字段的数据结构:

  • child 指向第一个child节点

  • sibling 指向第一个兄弟节点

  • return 指向父节点

在React中的新reconciliation算法的上下文中,具有这些字段的数据结构称为fiber,他们表示具有一个队列的worker需要完成的React元素,下图演示了通过链表链接的对象的层次结构以及它们之间的连接类型:

首先,看一下node节点的构造函数:

class Node {
constructor
(instance) {
this.instance = instance;
this.child = null;
this.sibling = null;
this.return = null;
}
}

然后我们看link函数如何把一组节点连接起来:

function link(parent, elements) {
if (elements === null) elements = [];

parent
.child = elements.reduceRight((previous, current) => {
const node = new Node(current);
node
.return = parent;
node
.sibling = previous;
return node;
}, null);

return parent.child;
}

link函数从节点数组中的最后一个节点开始迭代,并将它们链接在一个单独的链表中,它返子节点中的第一个,下面是一个简单的演示:

const children = [{name: 'b1'}, {name: 'b2'}];
const parent = new Node({name: 'a1'});
const child = link(parent, children);

// the following two statements are true
console
.log(child.instance.name === 'b1');
console
.log(child.sibling.instance === children[1]);

我们还将实现一个辅助函数,为节点执行一些工作。在我们的例子中,它将记录组件的名称。但除此之外,它还检索组件的子项并将它们链接在一起:

function doWork(node) {
console
.log(node.instance.name);
const children = node.instance.render();
return link(node, children);
}

前面的准备工作完成之后,接下来就是重头戏,深度优先遍历了:

unction walk(o) {
let root = o;
let current = o;

while (true) {
// perform work for a node, retrieve & link the children
let child = doWork(current);

// if there's a child, set it as the current active node
if (child) {
current
= child;
continue;
}

// if we've returned to the top, exit the function
if (current === root) {
return;
}

// keep going up until we find the sibling
while (!current.sibling) {

// if we've returned to the top, exit the function
if (!current.return || current.return === root) {
return;
}

// set the parent as the current active node
current
= current.return;
}

// if found, set the sibling as the current active node
current
= current.sibling;
}
}

虽然实现起来并不是特别难以理解,但也可以试玩一下加深理解。这里的算法是是保持对当前节点的引用,不断的遍历当前节点的第一个子节点,直到我们到达分支的末尾,然后通过return 这个引用来返回到父节点。

如果我们现在使用此实现检查调用堆栈,这是我们将要看到的内容:

正如您所看到的,当我们沿着树遍历时,堆栈不会增长。如果现在将调试器放入doWork函数并记录节点名称,我们将看到以下内容:

它看起来非常像浏览器中的调用栈,因此,使用这种算法,可以用自己的实现有效地替换浏览器的调用堆栈实现。这就是Andrew在这里所描述的:

Fiber是重新实现的堆栈,专门用于React组件。您可以将单个fiber视为虚拟堆栈帧。

因为我们现在通过保持对充当顶部框架的节点的引用来控制堆栈。

function walk(o) {
let root = o;
let current = o;

while (true) {
...

current
= child;
...

current
= current.return;
...

current
= current.sibling;
}
}

我们可以随时停止遍历并稍后恢复。这正是我们想要实现的能够使用新的requestIdleCallback API的条件。

React中的work loop

可以看react中的work loop源码

unction workLoop(isYieldy) {
if (!isYieldy) {
// Flush work without yielding
while (nextUnitOfWork !== null) {
nextUnitOfWork
= performUnitOfWork(nextUnitOfWork);
}
} else {
// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
nextUnitOfWork
= performUnitOfWork(nextUnitOfWork);
}
}
}

如您所见,它很好地映射到我上面提到的算法。它保留对nextUnitOfWork变量中当前光纤节点的引用,该变量充当顶部帧。 该算法可以同步遍历组件树,并为树中的每个光纤节点执行工作(nextUnitOfWork)。这通常是由UI事件(点击,输入等)引起的所谓交互式更新的情况。或者它可以异步地遍历组件树,检查在执行光纤节点工作后是否还剩下时间。函数shouldYield返回基于deadlineDidExpire和deadline变量的结果,这些变量在React为光纤节点执行工作时不断更新。 这里深入介绍了peformUnitOfWork函数。

可以看到,它很好地映射到上面提到的算法,它保留对nextUnitOfWork变量中当前fiber节点的引用,该变量充当栈顶。

该算法可以同步遍历组件树,并为树中的每个fiber节点执行工作(nextUnitOfWork)。这通常是由UI事件(点击,输入等)引起的所谓交互式更新的情况;或者它也可以异步地遍历组件树,检查在执行fiber节点work执行后是否还剩下时间。函数shouldYield返回基于deadlineDidExpire 和 deadline变量的结果,这些变量在React为fiber节点执行工作时不断更新。

关于本文
译者:@Richard
译文:https://zhuanlan.zhihu.com/p/57856350
作者:@Max Koretskyi aka Wizard
原文:https://medium.com/react-in-depth/the-how-and-why-on-reacts-usage-of-linked-list-in-fiber-67f1014d0eb7

最后为你推荐


【第1497期】React组件文档解决方案


【第1448期】深入理解 React 高阶组件


【图书】React 状态管理与同构实战

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存